"""
Trestle -- doctest for RESTful services
---------------------------------------

Trestle is a plugin for nose that is used to make web service
documentation runnable. The documentation files must be in
reStructureText format, and include fixtures, request and response
sections, like this::

  .. fixtures:: test.fixtures

  The application used by this self test is very simple: it always
  replies with 'Hello' to GET requests. Let's start the test examples
  with a simple GET request.
  
  .. request:: A simple get
  
    GET /foo
  
  .. 
  
  You can match on the whole response -- be sure to always include both
  the full status line and headers:
  
  .. response::
   
    200 Ok
    Content-type: text/plain
  
    Hello  
  ..
  
  Or just the content. Notice that multiple response blocks can be
  compared against the same request.
  
  .. response::
  
    Hello
  ..
  
  The output comparison is done using a doctest OutputChecker. The
  doctest extensions NORMALIZE_WHITESPACE and ELLIPSIS are always on, so
  this response also matches:
  
  .. response::
  
    200 ...
    ...
  
    He...o
  ..
  
  ----
  
  POST requests include the data to be posted after the request line.
  
  .. request:: A simple POST
  
    POST /foo
    a=1&b=2
  ..
  
  The simple application used in this self test always responds to POST
  requests by echoing back the posted parameters:
  
  .. response::
  
    You said: a=1 and b=2  
  ..
  
  Just like with GETs, you can match against headers and content or content
  only.
  
  .. response::
  
    200 Ok
    Content-type: text/plain
  
    You said: a=1 and b=2  
  ..

A fixtures directive is required in every document to be tested. The
directive must name a python module. The module must include the
following:

client
  A client application to be called with methods .get(), .post(),
  .put(), etc. Each method must accept at least the arguments url and
  data, and may accept others but may not require
  them. paste.fixtures.TestApp is such a client, though it natively
  supports only .get() and .post().

And may include the following:

setup_all(cls)
  Setup fixture run before the first request.

teardown_all(cls)
  Teardown fixture run after the last request.

setup_each(inst)
  Setup fixture run before each request.

teardown_each(inst)
  Teardown fixture run after each request.

Each request to be sent to using the client defined in fixtures is
specified used a request block, like this::

  .. request:: A simple get

    GET /foo/bar
  ..

If the request is a POST or PUT that includes data to be sent, include
that data in the body of the request, after the request line::

  .. request:: A post

    POST /foo/bar
    a=1&b=2 
  ..

The response expected to be returned to the client is defined using a
response block::

  .. response::

    Ponies!!1!
  ..


.. note:: Conclude each block with ``..`` alone on a line to avoid rst parsing
          errors that can result in text following a block being thrown away.
"""

import os
import logging
from pkg_resources import resource_filename

from docutils.core import publish_doctree, publish_from_doctree
from docutils.parsers.rst import directives
from docutils import nodes
from nose.plugins import Plugin

import trestle.rst

log = logging.getLogger(__name__)


class Trestle(Plugin):
    """
    Doctest for REST apps in ReST files. Parses reStructuredText
    documents looking for request and response sections, and
    constructs test examples that make the requests and expect to
    receive the response(s) defined for the requests. A fixtures file
    referenced by each reStructuredText document must be used to define
    class and test level fixtures, as well as the http client (or mock client)
    to be used to make the requests.
    """

    def options(self, parser, env=None):
        super(Trestle, self).options(parser, env)
        parser.add_option('--trestle-extension', action='store',
                          dest='trestle_extension', default='.rst',
                          help='Look for trestle tests in files with '
                          'this extension')
        parser.add_option('--trestle-css', action='store',
                          dest='trestle_css', default='css/rst.css',
                          help='Include this .css file in generated '
                          'html documentation files. Relative paths '
                          'are relative to the trestle package or your '
                          'working dir')
        parser.add_option('--trestle-output', action='store',
                          dest='trestle_output_dir', default='docs',
                          help='Output generated html documentation files '
                          'in this directory. Relative paths are relative '
                          'to your working dir.')

    def configure(self, options, conf):
        super(Trestle, self).configure(options, conf)
        if not self.enabled:
            return
        self.ext = options.trestle_extension
        self.css = options.trestle_css
        self.output_dir = os.path.abspath(options.trestle_output_dir)
        self.conf = conf
        self.docs = []

    def prepareTestLoader(self, loader):
        self.loader = loader

    def loadTestsFromFile(self, filename):
        if not filename.endswith(self.ext):
            return
        yield self.loader.loadTestsFromTestClass(self.createTestClass(filename))

    def report(self, stream):
        log.debug('trestle docs %s', self.docs)
        if not self.docs:
            return
        for filename, rst in self.docs:
            # update the examples in the document with
            # success/failure and the diff (if failed)
            #
            #set_trace()
            for node in rst.doc.traverse(lambda n: hasattr(n, 'example')):
                log.debug('found example node %s', node)
                example = node.example
                if example.passed:
                    node['classes'].append('pass')
                else:
                    # create a section node
                    # replace orig node with new
                    refid = str(hash(node))
                    parent = node.parent
                    sect = nodes.section()
                    sect['ids'] = [refid]
                    node['classes'].append('fail')
                    msg = nodes.literal_block(
                        example.message,
                        example.message, classes=['failure', 'output'])
                    txt = nodes.title()
                    txt.append(nodes.reference('', '', refid=refid))
                    sect.append(txt)
                    sect.append(node)
                    sect.append(msg)
                    parent.replace(node, sect)
                    self.addTocEntry(sect, example)
            self.writeDocs(filename, rst, stream)

    def addTocEntry(self, sect, example):
        enclosure = self.findSection(sect.parent)
        topic = self.findTocList(enclosure)
        if not topic:
            return

        title = nodes.Text("FAILED: %s" % example.description)
        reference = nodes.reference('', '', refid=sect['ids'][0],
                                    *(title,))
        ref_id = sect.document.set_id(reference)
        sect['refid'] = ref_id
        sect.document.ids[sect['ids'][0]] = sect
        entry = nodes.paragraph('', '', reference)
        item = nodes.list_item('', entry)
        item['classes'].append('fail')
        topic.append(item)

    def findTocList(self, section):
        id = section['ids'][0]
        doc = section.document
        try:
            cnt = doc.ids['contents']
        except KeyError:
            return
        refs = [r for r in
                cnt.traverse(lambda n: isinstance(n, nodes.reference))
                if r.get('refid') == id]
        if not refs:
            return
        ref = refs[0]
        top = ref.parent.parent.parent # the bullet-list
        if top.attributes.get('failures'):
            return top
        # find one that exists already
        blists = [n for n in top.children
                  if (isinstance(n, nodes.bullet_list)
                      and n.attributes.get('failures'))]
        if blists:
            return blists[0]
        # new one is needed
        new_blist = nodes.bullet_list()
        new_blist.attributes['failures'] = True
        ref.append(new_blist)
        return new_blist

    def findSection(self, node):
        if isinstance(node, nodes.section) or \
           isinstance(node, nodes.document):
            return node
        return self.findSection(node.parent)

    def writeDocs(self, filename, rst, stream):
        if not self.output_dir:
            return
        outfile = os.path.join(self.output_dir,
                               os.path.basename(filename)[:-4] + '.html')

        # css file paths may be relative to trestle or the working dir
        overrides = {}
        if self.css:
            if os.path.isabs(self.css):
                sheet = self.css
            else:
                local = os.path.abspath(self.css)
                if os.path.exists(local):
                    sheet = local
                else:
                    pkgd = resource_filename('trestle', self.css)
                    if os.path.exists(pkgd):
                        sheet = pkgd
                    else:
                        raise OSError("Unable to locate css file %s" % self.css)
            overrides['stylesheet_path'] = sheet

        res = publish_from_doctree(
            rst.doc, destination_path=outfile,
            writer_name='html',
            settings_overrides=overrides
            )

        if not os.path.isdir(self.output_dir):
            if os.path.isfile(self.output_dir):
                raise OSError("Output dir %s exists and is a file"
                              % self.output_dir)
            os.mkdir(self.output_dir)

        out = open(outfile, 'w')
        out.write(res)
        out.close()
        stream.writeln("Wrote trestle %s to %s" % (filename, outfile))

    def wantFile(self, filename):
        if filename.endswith(self.ext):
            return True

    def readFile(self, filename):
        return RstSuite(filename)

    def createTestClass(self, filename):
        """Create a test class for an rst file.

        The test class sets up the fixtures and yields a test for each example
        in the rst file.
        """
        rst = RstExampleReader(filename)
        self.docs.append((filename, rst))

        class RstTestClass(object):
            @classmethod
            def setup_class(cls):
                rst.setup_all(cls)

            @classmethod
            def teardown_class(cls):
                rst.teardown_all(cls)

            def setup(self):
                rst.setup(self)

            def teardown(self):
                rst.teardown(self)

            def test(self):
                client = rst.client()
                for example in rst.examples:
                    def case(client):
                        if example.setup is not None:
                            self._eval(example.setup,
                                       rst.fixture.__dict__)
                        try:
                            example(client)
                        finally:
                            if example.teardown is not None:
                                self._eval(example.teardown,
                                           rst.fixture.__dict__)
                    case.description = str(example)
                    yield case, client

            def _eval(self, eval_string, context):
                eval(eval_string, context)

        # put the test class in proper module context
        RstTestClass.__module__ = rst.fixture.__name__
        return RstTestClass


class RstExampleReader(object):
    """Read an .rst file, looking for 2 things:

    1. a fixture directive that indicates the module to load fixtures
       from. This is required and the module must at least define
       the client, that is the web client instance that will be
       passed to each Example to execute it.

    2. example directives, each of which becomes an Example instance,
       which is a runnable test that executes by using the client it
       is passed to call the method in the request, and expects the
       response received to match any following response directives.
    """
    def __init__(self, filename):
        self.fixture = None
        self.filename = filename
        self.examples = []
        for (name, cls) in (('fixtures', 'FixtureDirective'),
                            ('request', 'RequestDirective'),
                            ('response', 'ResponseDirective'),
                            ('shell', 'ShellDirective')):
            directives.register_directive(
                name,
                trestle.rst.suite_directive_factory(
                    self, getattr(trestle.rst, cls)))
        self.doc = publish_doctree(open(filename, 'r').read(),
                                   source_path=filename)

    def setup_all(self, cls):
        self._run_fixt('setup_all', cls)

    def teardown_all(self, cls):
        self._run_fixt('teardown_all', cls)

    def setup(self, inst):
        self._run_fixt('setup_each', inst)

    def teardown(self, inst):
        self._run_fixt('teardown_each', inst)

    def _run_fixt(self, name, *arg):
        fixture = getattr(self, 'fixture', None)
        if fixture:
            fixt = getattr(fixture, name, None)
            if fixt:
                fixt(*arg)

    def client(self):
        try:
            return self.fixture.client
        except AttributeError, e:
            raise AttributeError(
                "Trestle test file %s does not defined a fixture, "
                "or the fixture does not define the client, "
                "or the fixture could not be loaded. (%s)"
                % (self.filename, e))


